iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

昨天 D08 的 EXPLAIN ANALYZE 印出一條有點違反直覺的線:

row_groups_pruned_statistics=5 total → 5 matched
bytes_scanned=98.53 M
scan_efficiency_ratio=99.95% (98.53 M/98.57 M)

翻譯過來就是:昨天那句 ORDER BY id, v, s 一個 row group 都沒剪掉、幾乎讀了整個檔案。可是 Parquet 不是號稱「只讀該讀的位元組」嗎?

要看懂這條線印的是什麼、什麼時候會不一樣,得先知道 Parquet 檔案到底長什麼樣。
CSV 打開就是每列一行、人眼看得懂;Parquet 打開就是 binary,什麼都看不見。
今天就把它一層一層剖開。

四個問題連問:

  • Parquet 檔案內部到底怎麼排的?
  • footer 為什麼放在檔案尾巴而不是開頭?
  • 「row group pruning」憑什麼跳掉整段資料?
  • 為什麼昨天那句 ORDER BY 剪不掉,換一個 WHERE 就能剪掉 98% 的 I/O?

Parquet 的五層剖面

https://ithelp.ithome.com.tw/upload/images/20260825/20183544dECXmUIeYz.jpg

Parquet 是欄式檔案格式,從外往裡看是這樣:

Parquet File
├── Row Group 0                ← 一組列的水平切片
│   ├── Column Chunk (欄 1)    ← 這一組列的某一欄
│   │   ├── Dictionary Page   (可選,字典編碼用)
│   │   └── Data Page 1..N    (實際壓縮過的資料頁)
│   ├── Column Chunk (欄 2)
│   ├── Column Chunk (欄 3)
│   └── ...
├── Row Group 1
├── Row Group 2
├── ...
└── Footer                     ← metadata 住這裡:schema、每 RG 每欄的
                                   min/max、null count、page offsets

拆兩層講

Row Group 是一組「水平切開」的列,DataFusion 55.0 寫檔預設一個 RG 是 1,048,576 列(2²⁰);一份 500 萬列的檔就會被切成 5 個 RG。RG 之間互相不重疊、也不共用資料,是引擎剪枝的最小整段單位。

Column Chunk 是同一個 RG 裡「垂直切開」的一個欄,一個 4 欄的檔在每個 RG 裡有 4 個 column chunk,內部再切成一頁一頁的 Data Page。之所以要這樣切,就是欄式的核心命題——同一欄的資料連續放在一起、不同欄分開放SELECT g 只讀 g 那一段位元組,其他三欄連 disk seek 都不用付。

Parquet 也支援 nested schema(struct、list、map),data page 裡會多兩個小整數 repetition level 跟 definition level(來自 Google 的 Dremel 論文)讓引擎知道每個值屬於哪一層巢狀。這篇用的 sortbench.parquet 都是平的 primitive 欄,這件事之後有機會再挖。

Footer 為什麼放最後

打開 Parquet 檔案的第一步不是從頭開始讀——是先跳到檔案最後

[ ... 資料頁 ... ][ Footer ][ Footer length (4 bytes) ][ Magic "PAR1" (4 bytes) ]

檔案最後 8 個位元組是 4 bytes 的 footer 長度 + 4 bytes 的 magic PAR1。讀檔前先發一個 range request 拿最後這 8 KB 左右,就能同時驗證這是 parquet 檔、知道 footer 多大、順便把 footer 整份讀進來;然後才根據 footer 記的 page offset 回頭去讀真正要的資料頁。

為什麼放最後不放開頭?兩個理由:

append-only 寫入友善。寫檔的時候,metadata 是最後才能算完整的——RG 的 offset、每欄的 min/max、壓縮後大小,這些東西都要等資料寫完才知道。把 metadata 放最後,寫檔就是一路 append 資料頁、寫完最後補上 footer 就結束,不需要回頭改開頭的欄位。放開頭的話得先預留一段空間、寫完再回填,複雜一階。

物件儲存友善。Parquet 大量部署在 S3 / GCS / R2 這種物件儲存上,range GET 是最基本的 API。整個檔案 100 MB,只想看 metadata 決定要不要讀?range GET 最後 8 KB 就夠了。metadata 沒告訴你要繼續讀?連資料頁的位元組都不用付流量。

打開一份 parquet 看看

拿昨天 D08 造的那份 sortbench.parquet(98.57 MB,500 萬列,4 欄 id, g, v, s)開來看實際長怎樣。DataFusion 內建 parquet_metadata() 函式可以印出每個 RG 每個 column chunk 的細節:

SELECT row_group_id, path_in_schema, type,
       row_group_num_rows, stats_min, stats_max,
       total_compressed_size, total_uncompressed_size
FROM parquet_metadata('sortbench.parquet');

把每個 RG 的 id 這欄挑出來看:

RG 列數 id min id max 這個 RG 壓縮後大小
0 1,048,576 1 1,048,576 ~20.7 MB
1 1,048,576 1,048,577 2,097,152 ~20.7 MB
2 1,048,576 2,097,153 3,145,728 ~20.7 MB
3 1,048,576 3,145,729 4,194,304 ~20.7 MB
4 805,696 4,194,305 5,000,000 ~15.9 MB

一眼可以看到幾件事:

  • id遞增寫入的,每個 RG 的 min/max 不重疊——這是後面能剪枝的關鍵
  • g 每個 RG 的 min/max 都是 0..7(因為它 mod 8),完全無法用 min/max 剪枝
  • g 這欄未壓縮約 390 KB、壓縮後只有 2.6 KB(壓縮比 ~150 倍),因為 RLE (Run-Length Encoding) + dictionary 對 8 個相異值狂殺;s 是 md5 隨機字串,壓縮比只有 ~2 倍,占了每個 RG 大部分容量
  • 每個 RG 壓縮後 ~20 MB,五個 RG 加起來就是那 98.57 MB 的檔案

這對 D08 的實驗解釋了什麼

回到開頭那個「為什麼 D08 什麼都沒剪掉」的疑問。答案在引擎能不能拿 footer 的 min/max 去 match query 的 predicate

用同一份 sortbench.parquet 跑兩個對照組:

SET datafusion.execution.target_partitions = 1;

-- 用 id 當 WHERE:id 的 min/max 遞增不重疊,能剪
EXPLAIN ANALYZE
SELECT COUNT(*) FROM 'sortbench.parquet' WHERE id > 4000000;

-- 用 g 當 WHERE:g 的 min/max 每個 RG 都 0..7,剪不動
EXPLAIN ANALYZE
SELECT COUNT(*) FROM 'sortbench.parquet' WHERE g = 5;

DataSourceExec 那一行的關鍵指標比一比:

指標 WHERE id > 4000000 WHERE g = 5
row_groups_pruned_statistics 5 → 2 matched(剪掉 3 個 RG) 5 → 5 matched(沒剪任何 RG)
page_index_pages_pruned 52 → 10 matched 248 → 248 matched
bytes_scanned 1.41 MB 12.67 KB*
scan_efficiency_ratio 1.43% 0.01%*

id > 4000000 這條:footer 記 RG 0/1/2 的 max 分別是 1M / 2M / 3M,都 ≤ 4M,直接整段跳掉;RG 3(max 4,194,304,部分符合)跟 RG 4(min 4,194,305,全部符合)留下來繼續讀。5 個 RG 剪掉 3 個、剩下的 2 個內部再靠 page index 從 52 頁篩到 10 頁,最後只讀 1.41 MB。這就是 Parquet「只讀該讀的位元組」的樣子。

g = 5 這條的 bytes_scanned 更小(12 KB),看起來反而更好?別被騙——12 KB 只是因為 SELECT COUNT(*) 只需要 g 那一欄,而 g 全檔壓縮後總共就那麼多。剪枝完全沒發生(5 → 5、248 → 248),欄式讓「全讀」也很便宜而已。換成 SELECT * FROM ... WHERE g = 5,就會把整個 5 × 20 MB 全部拉進來。

回到 D08 那句 SELECT id, g, v, s FROM ... ORDER BY id, v, s

  • 沒有 WHERE → 任何 predicate pruning 都不會發生 → 5 個 RG 全留
  • SELECT 四個欄 → 每個 RG 都要讀完整 20 MB
  • 結果就是 bytes_scanned=98.53 M,幾乎整個檔案

不是 Parquet 沒 work,是那句 query 本來就沒東西可剪。

總結

一句話:Parquet 的「只讀該讀的位元組」是有條件的——條件是 query 的 projection 對得到 column chunk、predicate 對得到 footer 記的 min/max。兩個都對不到的時候,Parquet 就退化成一個「按欄排列的壓縮檔」,沒有魔法。

昨天 D08 的 bytes_scanned 幾乎全滿不是格式的錯,是那句 SQL 讓 Parquet 沒得幫忙。把 footer 看清楚之後就知道,換一個 predicate、或換一種資料排布(例如把 id 拿去當第一個 sort key 寫檔),同一份 Parquet 能省下 98% 的 I/O。

留一個沒有標準答案的問題:footer 記的 min/max 是「這個 RG 值域」的極粗近似,只能告訴你「可能有」不能告訴你「一定有」。要精確就得靠 bloom filter 或 page index,那是額外的 metadata。這兩層 metadata 該記到多細,才叫剛好?記多了 footer 會胖到 range GET 一次讀不完、記少了引擎又要多讀資料。

明天 D10 換一個尺度看資料,一張 Iceberg 表由多個 Parquet 檔組成,「該讀哪些檔」由更上一層的 catalog → metadata → manifest 決定。今天講的 footer 是檔案內的剪枝,明天講的 manifest 是檔案間的剪枝,兩層是遞迴同構的。

那就明天見~


上一篇
Day 08 DataFusion SortExec:多欄 ORDER BY 為什麼比單欄貴
下一篇
Day 10 Iceberg 四層結構:讀一張表要打開幾個檔案
系列文
1+1+1>3 ~ Spark 與 DataFusion、Comet 效能煉金術 ~21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言